//	Ordinaire.Database.ORMapper
//	Copyright (c) 2008 Jonathan Loe
//
//	MIT license (http://en.wikipedia.org/wiki/MIT_License)
//
//	Permission is hereby granted, free of charge, to any person obtaining a copy
//	of this software and associated documentation files (the "Software"), to deal
//	in the Software without restriction, including without limitation the rights 
//	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
//	of the Software, and to permit persons to whom the Software is furnished to do so, 
//	subject to the following conditions:
//
//	The above copyright notice and this permission notice shall be included in all 
//	copies or substantial portions of the Software.
//
//	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
//	INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
//	PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 
//	FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
//	ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Reflection;
using System.Runtime.Remoting;
using System.Text;
using System.Web;
using System.Xml;

namespace Ordinaire.Database
{
	/// <summary>
	/// ORMapper is a singleton class which loads one or more mapping files in xml format
	/// and does object-relational mapping (ORM) based on a particular mapping defined.
	/// </summary>
	public class ORMapper
	{
		#region Variables
		private struct ElementNames
		{
			public const string ROOT = "mappings";
		}

		// To keep single copy of ORMapper class as well as the mappings to be shared.
		private static ORMapper _instance;
		private static Dictionary<string, XmlNode> mappings;

		// used to share the same mapping across two methods (ie. ExecuteQuery & TransformRowToObject)
		private ORMapping orMapping;
		#endregion

		#region Constructor
		/// <summary>
		/// Private constructor of ORMapper.
		/// </summary>
		private ORMapper()
		{
			mappings = new Dictionary<string, XmlNode>();
		}

		/// <summary>
		/// Create a singleton of ORMapper.
		/// </summary>
		public static ORMapper Instance
		{
			get
			{
				if (instance == null) { instance = new ORMapper(); }
				return instance;
			}
		}
		#endregion

		#region Public methods
		#region Load Xml
		/// <summary>
		/// Load a single xml file.
		/// </summary>
		/// <param name="path">file path</param>
		public void Load(string path)
		{
			if (String.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); }

			BuildMappings(path);
		}

		/// <summary>
		/// Load stream of xml file.
		/// </summary>
		/// <param name="path">input stream</param>
		public void Load(Stream input)
		{
			if (input == null) { throw new ArgumentNullException("input"); }

			BuildMappings(input);
		}

		/// <summary>
		/// Load all xml files resides in the specified path - only top directory.
		/// </summary>
		/// <param name="path">path</param>
		public void LoadAll(string path)
		{
			if (String.IsNullOrEmpty(path)) { throw new ArgumentNullException("path"); }

			// Only list down all xml files available in the specified path
			string[] filepaths = Directory.GetFiles(path, "*.xml");
			for (int i = 0; i < filepaths.Length; i++)
			{
				string filepath = filepaths[i];
				BuildMappings(filepath);
			}
		}
		#endregion

		#region ExecuteQuery
		/// <summary>
		/// Executes non-query statement to return list of objects using default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(string name)
		{
			return ExecuteQuery(DatabaseFactory.Instance, name, null, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes non-query statement to return list of objects using default instance of DatabaseFactory class
		/// with the specified instance.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object used to parse command text</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(string name, object obj)
		{
			return ExecuteQuery(DatabaseFactory.Instance, name, obj, new Dictionary<string,object>());
		}

		/// <summary>
		/// Executes non-query statement to return list of objects using default instance of DatabaseFactory class
		/// with the specified parameters.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(string name, Dictionary<string, object> parameters)
		{
			return ExecuteQuery(DatabaseFactory.Instance, name, null, parameters);
		}

		/// <summary>
		/// Executes non-query statement to return list of objects.
		/// </summary>
		/// <param name="database">Database to use</param>
		/// <param name="name">mapping name</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(IDatabase database, string name)
		{
			return ExecuteQuery(database, name, null, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes non-query statement to return list of objects with the specified object instance.
		/// </summary>
		/// <param name="database">Database to use</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object used to parse command text</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(IDatabase database, string name, object obj)
		{
			return ExecuteQuery(database, name, obj, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes non-query statement to return list of objects with the specified parameter list.
		/// </summary>
		/// <param name="database">Database to use</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object used to parse command text</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(IDatabase database, string name, Dictionary<string, object> parameters)
		{
			return ExecuteQuery(database, name, null, parameters);
		}

		/// <summary>
		/// Executes non-query statement with the specified parameters to return list of objects.
		/// </summary>
		/// <param name="database">Database to use</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to parse command text</param>
		/// <param name="parameters">parameters</param>
		/// <returns>list of object</returns>
		public List<object> ExecuteQuery(IDatabase database, string name, object obj, Dictionary<string, object> parameters)
		{
			if (database == null) { throw new ArgumentNullException("database"); }
			if (String.IsNullOrEmpty(name)) { throw new ArgumentNullException("name"); }

			/* Start of validation */
			XmlNode mapping = mappings[name];
			if (mapping == null)
			{
				throw new ArgumentException(
					String.Format(ExceptionMessage.InvalidMappingNameFormat, name)
				);
			}
			orMapping = new ORMapping(mapping);

			// <object> must exist
			XmlNode eObject = orMapping.Object;
			if (eObject == null)
			{
				throw new ArgumentException(
					String.Format(ExceptionMessage.MissingElementFormat, ORMapping.ElementNames.OBJECT)
				);
			}
			/* End of validation */

			// Get command text and type
			string commandText = orMapping.CommandText;
			CommandType commandType = orMapping.CommandType;

			// Parse Command Text
			if (obj != null) { commandText = ParseCommandText(orMapping, obj); }

			List<object> results = new List<object>();
			if (parameters == null || parameters.Count == 0)
				results = database.ExecuteQuery(TransformToObject, commandText, commandType);
			else
				results = database.ExecuteQuery(TransformToObject, commandText, parameters, commandType);

			return results;
		}
		#endregion

		#region ExecuteNonQuery
		/// <summary>
		/// Executes non-query statement with the mapping name and object to use
		/// using default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(string name, object obj)
		{
			return ExecuteNonQuery(DatabaseFactory.Instance, name, obj, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes non-query statement with the mapping name and specified parameter list
		/// using default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(string name, Dictionary<string, object> parameters)
		{
			return ExecuteNonQuery(DatabaseFactory.Instance, name, null, parameters);
		}

		/// <summary>
		/// Executes non-query statement with the mapping name, object to use, and parameter list
		/// using default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(string name, object obj, Dictionary<string, object> parameters)
		{
			return ExecuteNonQuery(DatabaseFactory.Instance, name, obj, parameters);
		}

		/// <summary>
		/// Executes non-query statement with the specified DatabaseFactory, mapping name and object to use.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(IDatabase database, string name, object obj)
		{
			return ExecuteNonQuery(database, name, obj, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes non-query statement with the specified DatabaseFactory, mapping name and parameter list.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(IDatabase database, string name, Dictionary<string, object> parameters)
		{
			return ExecuteNonQuery(database, name, null, parameters);
		}

		/// <summary>
		/// Executes non-query statement with the specified DatabaseFactory, mapping name, object to use, and list of parameters.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>number of affected rows</returns>
		public int ExecuteNonQuery(IDatabase database, string name, object obj, Dictionary<string, object> parameters)
		{
			if (database == null) { throw new ArgumentNullException("database"); }
			if (String.IsNullOrEmpty(name)) { throw new ArgumentNullException("name"); }

			XmlNode mapping = mappings[name];
			if (mapping == null)
			{
				throw new ArgumentException(
					String.Format(ExceptionMessage.InvalidMappingNameFormat, name)
				);
			}
			// Initializes and validates
			ORMapping orMapping = new ORMapping(mapping);

			// Get command text
			string commandText = orMapping.CommandText;
			// Get command type
			CommandType commandType = orMapping.CommandType;

			// Parse Command Text
			if (obj != null) { commandText = ParseCommandText(orMapping, obj); }

			int noOfRowsAffected = 0;
			if (parameters == null || parameters.Count == 0)
				noOfRowsAffected = database.ExecuteNonQuery(commandText, commandType);
			else
				noOfRowsAffected = database.ExecuteNonQuery(commandText, parameters, commandType);

			return noOfRowsAffected;
		}
		#endregion

		#region ExecuteScalar
		/// <summary>
		/// Executes scalar of SQL statement with the specified mapping name and object to use
		/// using the default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <returns>returned object</returns>
		public object ExecuteScalar(string name, object obj)
		{
			return ExecuteScalar(DatabaseFactory.Instance, name, obj, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes scalar of SQL statement with the specified mapping name and parameter list
		/// using the default instance of DatabaseFactory class.
		/// </summary>
		/// <param name="name">mapping name</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>returned object</returns>
		public object ExecuteScalar(string name, Dictionary<string, object> parameters)
		{
			return ExecuteScalar(DatabaseFactory.Instance, name, null, parameters);
		}

		/// <summary>
		/// Executes scalar of SQL statement with the specified DatabaseFactory, mapping name, and object to use.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <returns>returned object</returns>
		public object ExecuteScalar(IDatabase database, string name, object obj)
		{
			return ExecuteScalar(database, name, obj, new Dictionary<string, object>());
		}

		/// <summary>
		/// Executes scalar of SQL statement with the specified DatabaseFactory, mapping name, and parameter list.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>returned object</returns>
		public object ExecuteScalar(IDatabase database, string name, Dictionary<string, object> parameters)
		{
			return ExecuteScalar(database, name, null, parameters);
		}

		/// <summary>
		/// Executes scalar of SQL statement with the specified DatabaseFactory, mapping name, object to use, and list of parameters.
		/// </summary>
		/// <param name="database">instance of IDatabase class</param>
		/// <param name="name">mapping name</param>
		/// <param name="obj">object to use</param>
		/// <param name="parameters">list of parameters</param>
		/// <returns>returned object</returns>
		public object ExecuteScalar(IDatabase database, string name, object obj, Dictionary<string, object> parameters)
		{
			if (database == null) { throw new ArgumentNullException("database"); }
			if (String.IsNullOrEmpty(name)) { throw new ArgumentNullException("name"); }

			/* Start of validation */
			XmlNode mapping = mappings[name];
			if (mapping == null)
			{
				throw new ArgumentException(
					String.Format(ExceptionMessage.InvalidMappingNameFormat, name)
				);
			}
			// Use mapping wrapper to validate
			ORMapping orMapping = new ORMapping(mapping);

			// Get command text
			string commandText = orMapping.CommandText;
			// Get command type
			CommandType commandType = orMapping.CommandType;

			// Parse Command Text
			if (obj != null) { commandText = ParseCommandText(orMapping, obj); }

			object returnObject = null;
			if (parameters == null || parameters.Count == 0)
				returnObject = database.ExecuteScalar(commandText, commandType);
			else
				returnObject = database.ExecuteScalar(commandText, parameters, commandType);

			return returnObject;
		}
		#endregion
		#endregion

		#region Private methods
		/// <summary>
		/// Transform row to specified object in the mapping.
		/// </summary>
		/// <param name="row">list of rows</param>
		/// <returns>an object</returns>
		private object TransformToObject(Dictionary<string, object> row)
		{
			if (row == null) { throw new ArgumentNullException("row"); }

			// Create new object of specified typename
			ObjectHandle oh = Activator.CreateInstance(orMapping.ObjectAssemblyName, orMapping.ObjectTypeName);
			object obj = oh.Unwrap();

			Dictionary<string, string> mappingMembers = orMapping.Members;
			MembersMode mode = orMapping.MembersMode;

			Type objType = obj.GetType();
			PropertyInfo[] propertyInfos = objType.GetProperties();
			for (int i = 0; i < propertyInfos.Length; i++)
			{
				PropertyInfo info = propertyInfos[i];
				string columnName = info.Name;

				if (mappingMembers.ContainsKey(columnName)) { columnName = mappingMembers[columnName]; }
				// if column name is not found and mode is set to 'exact' then go to the next column
				else if (mode == MembersMode.Exact) { continue; }

				try
				{
					object value = row[columnName];
					if (value == null || value is DBNull) { continue; }
					info.SetValue(obj, value, null);
				}
					// ignore if column name doesn't exist
				catch (KeyNotFoundException) { continue; }
			}

			return obj;
		}

		/// <summary>
		/// Replaces any special word with the object value based on the member mappings if any.
		/// </summary>
		/// <param name="commandText">command text</param>
		/// <param name="obj">object</param>
		/// <param name="mapping">mapping entry</param>
		/// <returns>command text</returns>
		private string ParseCommandText(ORMapping mapping, Object obj)
		{
			String commandText = mapping.CommandText;

			Dictionary<string, string> members = mapping.Members;
			MembersMode mode = mapping.MembersMode;

			Type type = obj.GetType();
			PropertyInfo[] propertyInfos = type.GetProperties();
			for (int i = 0; i < propertyInfos.Length; i++)
			{
				PropertyInfo propertyInfo = propertyInfos[i];
				string columnName = propertyInfo.Name;

				if (members.ContainsKey(columnName)) { columnName = members[columnName]; }
				else if (mode == MembersMode.Exact) { continue; }

				object columnValue = propertyInfo.GetValue(obj, null);
				if (columnName == null) { continue; }

				// To prevent SQL injection by escaping any single-quote
				string valueString = String.Empty;
				if (columnValue is string) { valueString = ((string)columnValue).Replace("'", "''"); }
				else { valueString = Convert.ToString(columnValue); }

				commandText = commandText.Replace("{" + columnName + "}", valueString);
			}

			return commandText;
		}

		/// <summary>
		/// Returns ORMapper instance from a stored session for web application. Each session
		/// keeps a single instance in order to avoid conflicts of use caused by simultaneous
		/// access.
		/// </summary>
		private static ORMapper instance
		{
			get
			{
				if (HttpContext.Current != null && HttpContext.Current.Session != null)
				{
					object session = HttpContext.Current.Session[SessionName.OR_MAPPER];

					if (session != null) { return (ORMapper)session; }
					// Store existing instance to session if does not exist yet
					if (session == null && _instance != null)
					{
						instance = _instance;
						return instance;
					}

					return null;
				}
				else { return _instance; }
			}
			set
			{
				if (HttpContext.Current != null && HttpContext.Current.Session != null)
				{
					HttpContext.Current.Session.Remove(SessionName.OR_MAPPER);
					HttpContext.Current.Session.Add(SessionName.OR_MAPPER, value);
				}
				else { _instance = value; }
			}
		}

		/// <summary>
		/// Build mappings based on the xml path specified.
		/// </summary>
		/// <param name="input">path to the xml file to be read</param>
		private static void BuildMappings(string path)
		{
			XmlTextReader reader = new XmlTextReader(path);
			BuildMappings(reader, path);
		}

		/// <summary>
		/// Build mappings based on the stream read.
		/// </summary>
		/// <param name="input">stream to be read</param>
		private static void BuildMappings(Stream input)
		{
			XmlTextReader reader = new XmlTextReader(input);
			BuildMappings(reader, String.Empty);
		}

		/// <summary>
		/// Build mappings based on the Xml stream reader.
		/// </summary>
		/// <param name="reader">Xml stream reader</param>
		private static void BuildMappings(XmlTextReader reader, string path)
		{
			XmlDocument doc = new XmlDocument();

			doc.Load(reader);

			XmlNode root = doc[ElementNames.ROOT];
			// document is not a valid xml document.
			if (root == null)
			{
				throw new FormatException(
					String.Format(ExceptionMessage.InvalidMappingFileFormat, path)
				);
			}

			// Get the attribute if any
			XmlAttribute nameAttribute = root.Attributes[ORMapping.AttributeNames.NAME];
			string rootName = (nameAttribute == null) ? String.Empty : nameAttribute.Value;

			// if attribute 'name' is not specified, use the filename instead.
			if (String.IsNullOrEmpty(rootName) && !String.IsNullOrEmpty(path))
			{
				FileInfo file = new FileInfo(path);
				rootName = file.Name.ToLower().Replace(".xml", String.Empty);
			}

			for (int i = 0; i < root.ChildNodes.Count; i++)
			{
				XmlNode mapping = root.ChildNodes[i];
				if (!mapping.Name.Equals(ORMapping.ElementNames.MAPPING))
				{
					throw new FormatException(
						String.Format(ExceptionMessage.InvalidElementFormat, mapping.Name)
					);
				}

				//document.AppendChild(document.ImportNode(mapping, true));

				nameAttribute = mapping.Attributes[ORMapping.AttributeNames.NAME];

				// Throws exception if mapping name is not given
				if (nameAttribute == null)
				{
					throw new FormatException(
						String.Format(ExceptionMessage.UnnamedMappingInFileFormat, path)
					);
				}
				string name = nameAttribute.Value;

				// Validates Xml
				ORMapping.Validate(mapping);

				// add mapping name to the dictionary if not exist yet
				// replace if already exist
				if (!mappings.ContainsKey(name)) { mappings.Add(name, mapping); }
				else { mappings[name] = mapping; }

				string ns = String.Format("{0}.{1}", rootName, name);
				mappings.Add(ns, mapping);
			}

			reader.Close();
		}
		#endregion
	}
}
